Skip to main content

Decorator Pattern

Decorator Pattern in JS

The Decorator Pattern lets you add behavior to an object or function without modifying its original code.

Think of it like this:

"I want to wrap this thing with extra behavior — logging, caching, auth — without touching its core logic."


What is Decorator Pattern?

It is a structural design pattern where:

  • you wrap an existing function or object with another function/object
  • the wrapper adds behavior before, after, or around the original
  • the original code stays unchanged

This is the JavaScript equivalent of middleware — but applied at the function or class level.


Real-world analogy

Think of a coffee order:

  • You start with plain coffee
  • Add milk → still coffee, now with milk
  • Add sugar → still coffee, with milk and sugar

Each addition wraps the previous one. The original coffee didn't change — you decorated it.


Function Decorator (Higher-Order Function)

In JavaScript, functions are first-class. The most natural decorator is a higher-order function — a function that takes a function and returns a new function.

function withLogging(fn) {
return async function (...args) {
console.log(`[${fn.name}] called with`, args);
const result = await fn(...args);
console.log(`[${fn.name}] returned`, result);
return result;
};
}

Usage

async function getUser(id) {
return db.query('SELECT * FROM users WHERE id = $1', [id]);
}

const getUSerWithLogging = withLogging(getUser);
await getUSerWithLogging(42);
// [getUser] called with [42]
// [getUser] returned { id: 42, name: 'Prajwal' }

The original getUser is untouched.


Real backend decorators

1) Caching Decorator

function withCache(fn, cache, ttlSeconds = 300) {
return async function (...args) {
const key = `${fn.name}:${JSON.stringify(args)}`;

const cached = await cache.get(key);
if (cached) {
console.log(`[cache hit] ${key}`);
return JSON.parse(cached);
}

const result = await fn(...args);

await cache.set(key, JSON.stringify(result), ttlSeconds);
console.log(`[cache set] ${key}`);

return result;
};
}

Usage

async function getProduct(productId) {
return db.query('SELECT * FROM products WHERE id = $1', [productId]);
}

const getCachedProduct = withCache(getProduct, redisClient, 600);

// First call — hits DB, sets cache
await getCachedProduct('PROD001');

// Second call — returns from cache
await getCachedProduct('PROD001');

You didn't touch getProduct. You layered caching on top.


2) Retry Decorator

function withRetry(fn, maxRetries = 3, delayMs = 1000) {
return async function (...args) {
let lastError;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn(...args);
} catch (err) {
lastError = err;
console.warn(`[retry] attempt ${attempt}/${maxRetries} failed: ${err.message}`);

if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, delayMs * attempt));
}
}
}

throw lastError;
};
}

Usage

async function callExternalApi(payload) {
const res = await fetch('https://api.partner.com/shipment', {
method: 'POST',
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}

const callWithRetry = withRetry(callExternalApi, 3, 500);
await callWithRetry({ orderId: 'ORD123' });

3) Timing / Performance Decorator

function withTiming(fn) {
return async function (...args) {
const start = performance.now();
const result = await fn(...args);
const duration = (performance.now() - start).toFixed(2);
console.log(`[perf] ${fn.name} took ${duration}ms`);
return result;
};
}

4) Auth Guard Decorator

function requireAuth(fn) {
return async function (req, res, ...rest) {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
return fn(req, res, ...rest);
};
}

function requireRole(role, fn) {
return async function (req, res, ...rest) {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (req.user.role !== role) {
return res.status(403).json({ error: 'Forbidden' });
}
return fn(req, res, ...rest);
};
}

Usage

async function deleteUser(req, res) {
await userService.delete(req.params.id);
res.json({ success: true });
}

// Wrap with auth check — no changes to deleteUser
router.delete('/users/:id', requireRole('admin', deleteUser));

5) Rate Limiting Decorator

function withRateLimit(fn, maxCalls, windowMs) {
const callLog = new Map(); // userId → [timestamps]

return async function (userId, ...args) {
const now = Date.now();
const windowStart = now - windowMs;

const calls = (callLog.get(userId) || []).filter(t => t > windowStart);

if (calls.length >= maxCalls) {
throw new Error(`Rate limit exceeded for user ${userId}`);
}

calls.push(now);
callLog.set(userId, calls);

return fn(userId, ...args);
};
}

Composing Multiple Decorators

The real power: stack decorators to build complex behavior.

function compose(...decorators) {
return (fn) => decorators.reduceRight((wrapped, decorator) => decorator(wrapped), fn);
}

Usage

async function fetchUserData(userId) {
return db.getUser(userId);
}

const enhance = compose(
withLogging,
(fn) => withCache(fn, redis, 300),
(fn) => withRetry(fn, 3),
withTiming,
);

const fetchUser = enhance(fetchUserData);

await fetchUser(42);
// [perf] fetchUserData took 12.34ms
// [cache set] fetchUserData:[42]
// [fetchUserData] returned { id: 42 }

Each decorator does one thing. The composition does everything.


Class Method Decorator (Manual)

Without TypeScript decorators, apply them directly in the constructor:

class UserService {
constructor(db, cache) {
this.db = db;
this.cache = cache;

// Decorate specific methods at instantiation time
this.getUser = withCache(this.getUser.bind(this), cache);
this.getUser = withLogging(this.getUser);
this.deleteUser = requireAuth(this.deleteUser.bind(this));
}

async getUser(id) {
const { rows } = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return rows[0];
}

async deleteUser(req, res) {
await this.db.query('DELETE FROM users WHERE id = $1', [req.params.id]);
res.json({ deleted: true });
}
}

Decorator as a Wrapper Object

For class instances, you can wrap the whole object:

class LoggingProxy {
constructor(service) {
return new Proxy(service, {
get(target, prop) {
const original = target[prop];
if (typeof original !== 'function') return original;

return async function (...args) {
console.log(`[${target.constructor.name}.${prop}] called`);
const result = await original.apply(target, args);
console.log(`[${target.constructor.name}.${prop}] done`);
return result;
};
}
});
}
}

// Usage
const rawService = new PaymentService(stripeClient);
const service = new LoggingProxy(rawService);

await service.charge(userId, amount);
// [PaymentService.charge] called
// [PaymentService.charge] done

This wraps all methods automatically without touching each one individually.


Real-world production pattern

A typical service function with layered decorators:

// Base function
async function processOrder(orderId) {
const order = await db.getOrder(orderId);
await inventoryService.reserve(order);
await paymentService.charge(order);
await notificationService.confirm(order);
return order;
}

// Add cross-cutting concerns
const processOrderSafe = withRetry(
withTiming(
withLogging(processOrder)
),
3, // retries
1000 // delay
);

// Use everywhere
await processOrderSafe('ORD123');

Cross-cutting concerns (logging, timing, retry) are completely separate from business logic.


Benefits

1. Single Responsibility — each decorator does one thing.

2. Open/Closed Principle — add behavior without modifying the original.

3. Composable — stack decorators to build complex behavior from simple parts.

4. Testable — test the core function and each decorator separately.

// Test core logic without any cross-cutting concerns
test('processOrder charges the right amount', async () => {
await processOrder('ORD123'); // no logging, no retry
expect(mockPayment.charge).toHaveBeenCalledWith(/* ... */);
});

// Test decorator in isolation
test('withRetry retries 3 times on failure', async () => {
const flaky = jest.fn()
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValue('ok');

const safe = withRetry(flaky, 3, 0);
await safe();
expect(flaky).toHaveBeenCalledTimes(2);
});

When to use Decorator Pattern

  • logging — add logging to any function without modifying it
  • caching — layer caching on expensive calls
  • retry logic — wrap flaky external API calls
  • auth/permissions — guard functions without polluting business logic
  • rate limiting — per-user throttling on specific operations
  • performance monitoring — timing without modifying core logic
  • validation — wrap with input/output validation

When NOT to use it

  • when a simple if inside the function is cleaner
  • when wrapping causes confusion about what this refers to (watch for binding issues)
  • when stack traces become too hard to read due to many wrapper layers

Interview definition (short answer)

"Decorator Pattern adds behavior to a function or object without modifying its source code, using higher-order functions or wrapper objects. It enables composable cross-cutting concerns like logging, caching, and retries."


Formula:

originalFn → decorator(originalFn) → enhancedFn
compose(decorator1, decorator2, decorator3)(originalFn) → fullyEnhancedFn